在結束提升的討論後,我們來到作用域中的另一個謎題:閉包(Closure)。
閉包在 JS 的程式碼中扮演隱微卻關鍵的角色,函式替作用域創造了封閉性,閉包卻把函式內的秘密帶了出來。
讓我們看看以下程式碼:
function makeClosure() {
let a = 2;
function callIt() {
console.log( a );
}
return callIt;
}
let foo = makeClosure();
foo(); // 2
學習並實踐過 JS 的人,一定不會對這段程式碼有什麼疑問,但結合前面對作用域的描述,不覺得上面的執行結果有什麼不對嗎?
按照作用域規則,函式 makeClosure
內部的一切對外應該是保密的,並且在 makeClosure()
執行完畢後,function makeClosure
應該就被記憶體回收了。那麼調用 foo()
後,為什麼能夠順利取得 makeClosure
內部宣告的變數,打印出 2
?
你或許猜到了,關鍵在於 makeClosure()
的執行結果回傳了 callIt
這個函式,並賦值給 foo
,等於打開了一個讓 callIt
來到外部作用域的通道。
而我們能看到,被轉存到 foo
裡面的 callIt
被調用時,並沒有對 console.log( a );
該打印誰感到疑問,它順利找到 a
指向的參考,毫無錯誤地運行完成。
如此能夠清晰地看見,當函式 callIt
被回傳並賦值給 foo
時,它所能存取的作用域範疇也一併被保留下來,以供 foo
調用時使用,這就是所謂的「閉包」。
閉包包裹了函式 callIt
以及它能夠訪問的作用域,讓原本屬於 callIt
的函式內容能在 makeClosure
內部以外的地方順利執行。
達成閉包一個重要的概念是「一級函式」,也就是函式被當作頭等公民(first class),和字串、數字一樣能夠被當成參數傳遞,也能夠作為返回值。
關於閉包,這裡再探討深入一些:
function aFunc(x) {
return function () {
console.log(x++);
}
}
const newFunc = aFunc(1);
newFunc();
newFunc();
執行上面程式碼後,你預期會出現什麼呢?
按照前面的說明,我們已經知道由於閉包的威力, newFunc
裡面被賦值的內容,除了一個函式之外,還包括了這個函式能夠存取的作用域。
所以說,存活於 aFunc
內部的變數 x
,它在被調用時就被固定住了嗎?還是依然能夠作為一個變數,擁有改變內容的特性?
如果實際執行過上面的程式碼,會發現兩次 newFunc()
調用分別輸出了 1
和 2
。
也就是說,aFunc(1)
執行完將函式作為結果回傳給 newFunc
後,newFunc()
每次執行時,都更改了原本屬於 aFunc
內部的 x
參數。
aFunc
內部的作用域不但被完整保留了下來,甚至能夠改變內部的變數,表示閉包內儲存的作用域,其保存的內容依然是參考(reference)。
那麼,不論 aFunc
被調用了幾次,裡面全部都指向同個參考嗎?
我們用下面的程式碼來探討這個問題:
function addNum(x) {
return function (y) {
console.log(`x=${x} y=${y} x+y=${x + y}`);
}
}
const addOne = addNum(1);
const addTen = addNum(10);
addOne(4); // x=1 y=4 x+y=5
addTen(2); // x=10 y=2 x+y=12
addOne(56); // x=1 y=56 x+y=57
addTen(90); // x=10 y=90 x+y=100
對 function(y){...}
來說,x
是一個自由變數,是閉包效果保留下來而未被回收的變數。
自由變數:未在函式內部定義,卻在該函式中被使用的變數,對這個函式來說就是一個「自由變數(free variable)」。
從上面的程式碼可以看到,addOne
和 addTen
內部原本的參數 x
彼並不會互相影響的,它們在 addNum(1)
和 addNum(10)
調用時分別形成,而且彼此獨立運作,毫無關聯。
這裡我們能夠總結,閉包是函式在調用時創造的,並且每次調用都會仿造函式內部狀態,創造獨立的作用域環境。而回傳給外部的函式,以及函式為它處理好的獨立作用域環境,就是所謂的「閉包」。
這裡讓我們多看幾個例子:
setTimeout
function wait(message) {
setTimeout(function timer() {
console.log(message);
}, 1000);
}
wait("Hello, closure!");
上面的函式中,等到 1000 毫秒計時完畢,整個程式理論上早就執行完並回收乾淨了。
但我們最後依然能夠看到 Hello, closure!
這行字,因為閉包將尚未執行的函式 timer
連同其作用域訪問權保留了下來,等到 1000 毫秒後打印出這行字。
for 迴圈
for (let i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i);
}, i * 1000);
}
由於 let
製造了區塊作用域,console.log(i)
每次被丟入計時器等待執行時,都複製了 for
作用域當下的狀態,因此能夠依序跳出 1
、2
、3
、4
、5
。